実践 TDD
https://www.notion.so/TDD-30e13153eee24b4da5e4f8a5d3916429
# TDD (test-driven development): テスト駆動開発
「動作する綺麗なコード」
これがTDDのゴールです。
## 動作する綺麗なコード
- 開発が予測可能になり、完成したかどうかやバグの有無がわかりやすくなる。
- コードが伝えようとしていることが明白になる。
# TDDのシンプルな二つのルール
- 自動化されたテストが失敗した時のみ新しいコードを書く。
- 重複を除去する。
# TDDは以下のリズムで開発を行う
1. まずはテストを1つかく
2. 全てのテストを走らせ、新しいテストの失敗を確認する
3. 小さな変更を行う
4. 全てのテストを走らせ、全て成功することを確認する
5. リファクタリングを行って重複を除去する
必ず実装コードに変更を加える前にテストコードを書くことで、
必ずテストファーストな開発となる。
上記のリズム外で、随時TODOリストをアップデートしていく。
この勉強会ではTDDのリズムを実感してもらうため、
冗長な表現があります。
# 作るサンプルプログラム
5 ドル + 300 円 = 8 ドル
5 ドル + 300 円 = 800 円
※ 1ドル=100円
金額を入力し、足し合わされた金額を表示するプログラムを書く。
計算結果の通貨は選択できる。
ロジック部分、view部分共にTDDで開発していく。
# 最初のTODOリスト
ロジック
- 変換する
- 3ドルを300円に
- 300円を3ドルに
- 足し算
- 3ドル+4ドル=7ドル
- 300円+400円=700円
表示部分
数値の入力とその通貨の種類を表示するコンポーネント
- 通貨の種類を表示する
- 入力値を受け取る
- その種類と数値を親に渡す
結果の表示とその通貨の種類の選択をするコンポーネント
- 数値を表示する
- 通貨の種類を表示する
- 通貨の種類の変更を受け取る
発表ではロジック部分のみとしますが、
記事には表示部分も書きます。
長くなるので、記事を分けるかも。
# テストを一つ書く
TODOリストから一つやることを決め、そのテストを書いていきます。
一番簡単そうなやつから。
- 足し算
- 3ドル+4ドル=7ドル ←これ
- 300円+400円=700円
`jsx
import { Doller } from "#/models/Doller";
test("doller addition", () => {
const three: Doller = new Doller(3);
const four: Doller = new Doller(4);
three.add(four);
expect(three.amount).toBe(7);
})
`
😅「副作用が...」
😅「amountがpublic...」
↑あとで対処するために
TODOリストを増やしておきます。
- 足し算
- 3ドル+4ドル=7ドル
- **副作用(threeなのに7ドルになってしまう問題)どうする?**
- **プロパティがpublic問題**
- 300円+400円=700円
ここで書いたテストはまだコンパイルすらできないので、テストを動かす前にコンパイルエラーを解消します。
Dollerクラスを作成します。
`jsx
export class Doller {
public amount: number;
constructor(amount: number) {
this.amount = amount
}
add(doller: Doller): void {}
}
`
# テスト実行
ちなみに
`jsx
% yarn add --dev jest
`
でjestを使ってます。
`
// package.json
"scripts": {
"test": "jest"
}
`
`jsx
% yarn test
`
失敗しました。
!https://s3-us-west-2.amazonaws.com/secure.notion-static.com/8be1d092-a09f-4c2b-b9af-2b08cd821abd/_2020-11-01_19.10.15.png(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/8be1d092-a09f-4c2b-b9af-2b08cd821abd/_2020-11-01_19.10.15.png)
# テストをグリーンにするために小さい変更を行う
`jsx
export class Doller {
public amount: number
constructor(amount: number) {
this.amount = amount
}
add(doller: Doller): void {
this.amount = 7
}
}
`
addの関数でamountに7を入れるようにした。
`jsx
import { Doller } from "#/models/Doller";
test("doller addition", () => {
const three: Doller = new Doller(3);
const four: Doller = new Doller(4);
three.add(four);
expect(three.amount).toBe(7);
})
`
これでテストはグリーンです。
!https://s3-us-west-2.amazonaws.com/secure.notion-static.com/b884fc1a-841e-4183-86ff-adb3d6ad8010/_2020-11-01_19.16.35.png(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/b884fc1a-841e-4183-86ff-adb3d6ad8010/_2020-11-01_19.16.35.png)
# 重複を削除する
いま、テストコードと実装コードで共に7という数字がある。
実装コードから7を消していく。
そもそもこの7という数字はなんなのか?
3と4を足したもの。
`jsx
export class Doller {
public amount: number
constructor(amount: number) {
this.amount = amount
}
add(doller: Doller): void {
this.amount = 3 + 4
}
}
`
ここで再度テストを実行する。
もちろんグリーンだ。
じゃあこの3と4はなんなのか。
`jsx
export class Doller {
public amount: number
constructor(amount: number) {
this.amount = amount
}
add(doller: Doller): void {
this.amount = this.amount + doller.amount
}
}
`
テストを実行する。
グリーンだ。
TODOリストを更新します。
- 足し算
- x 3ドル+4ドル=7ドル
- 副作用(threeなのに7ドルになってしまう問題)どうする?
- プロパティがpublic問題
- 300円+400円=700円
# 動作する綺麗なコード
目指すのは動作する綺麗なコードです。
- **副作用(threeなのに7ドルになってしまう問題)どうする?**
綺麗さを追求するためにテストを追加します。
`jsx
import { Doller } from "#/models/Doller";
test("doller addition", () => {
const three: Doller = new Doller(3);
const four: Doller = new Doller(4);
three.add(four);
expect(three.amount).toBe(7);
const five: Doller = new Doller(5);
three.add(five);
expect(three.amount).toBe(8);
})
`
3に5を足したら8になる。
というテストを書きます。
しかし、これを実行するとレッドになります。
!https://s3-us-west-2.amazonaws.com/secure.notion-static.com/a9864238-db77-4d9c-857b-59643b7080a5/_2020-11-01_19.33.08.png(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/a9864238-db77-4d9c-857b-59643b7080a5/_2020-11-01_19.33.08.png)
three.amountは7なので、5を足すと12になります。
addで新しいオブジェクトを返すようにします。
`jsx
export class Doller {
public amount: number
constructor(amount: number) {
this.amount = amount
}
add(doller: Doller): void {
return new Doller(this.amount + doller.amount)
}
}
`
ここでテストを実行します。
二つともレッドになります。
テストも受け取るような形に書き換えます。
`jsx
import { Doller } from "#/models/Doller";
test("doller addition", () => {
const three: Doller = new Doller(3);
const four: Doller = new Doller(4);
const reslut: Doller = three.add(four);
expect(reslut.amount).toBe(7);
const five: Doller = new Doller(5);
const reslut2: Doller = three.add(five);
expect(reslut2.amount).toBe(8);
})
`
これでテストを実行します。
グリーンです。
- 足し算
- x 3ドル+4ドル=7ドル
- x **副作用(threeなのに7ドルになってしまう問題)どうする?**
- プロパティがpublic問題
- 300円+400円=700円
# 二つ目のテストを書く
`jsx
import { Doller } from "#/models/Doller";
import { Yen } from "#/models/Yen";
test("doller addition", () => {
// 略
})
test("yen addition", () => {
const threeHundred: Yen = new Yen(300);
const fourHundred: Yen = new Yen(400);
const reslut: Yen = threeHundred.add(fourHundred);
expect(reslut.amount).toBe(700);
const fiveHundred: Yen = new Yen(500);
const reslut2: Yen = threeHundred.add(fiveHundred);
expect(reslut2.amount).toBe(800);
})
`
そのままコピペしてYenに変えました。
Yenクラスがないのでコンパイルエラーです。
Dollerクラスをそのまま引用してクラス作成。
`jsx
export class Yen {
public amount: number
constructor(amount: number) {
this.amount = amount
}
add(yen: Yen): Yen {
return new Yen(this.amount + yen.amount)
}
}
`
テストを実行します。
グリーンです。
しかし、ここで重複が大量に発生しました。TODOリストに追加します。
- 足し算
- x 3ドル+4ドル=7ドル
- x **副作用(threeなのに7ドルになってしまう問題)どうする?**
- プロパティがpublic問題
- x 300円+400円=700円
- DollerとYenの重複 ← これ
メモ: public問題は、DollerとYenを共通化する気配があったから後回しにした。
# 重複の削除
外側からの動きは変わらないので、テストは追加しません。
実装コードを変えつつテストを実行していきます。
Doller - Yen
Money → Doller, Yen
にしていきます。
`jsx
export class Money {
public amount: number
constructor(amount: number) {
this.amount = amount
}
}
`
`jsx
import { Money } from "#/models/Money";
export class Doller extends Money {
constructor(amount: number) {
super(amount)
}
add(doller: Doller): Doller {
return new Doller(this.amount + doller.amount)
}
}
`
`jsx
import { Money } from "#/models/Money";
export class Yen extends Money {
constructor(amount: number) {
super(amount)
}
add(yen: Yen): Yen {
return new Yen(this.amount + yen.amount)
}
}
`
ここで一応テストを実行してテストがグリーンであることを確認する。
addもほぼ被っているので、共通化したい。
共通化するため、二つのクラスのaddをMoneyを返すように変更する。
`jsx
add(yen: Yen): Money {
return new Money(this.amount + yen.amount)
}
add(doller: Doller): Money {
return new Money(this.amount + doller.amount)
}
`
ここまでくると、この二つのサブクラスがほぼいらないことに気づく。
削除することにした。
# サブクラスの削除
これもテストから変えていく。
まずオブジェクト生成部分からサブクラスの存在を消した。
`jsx
import { Doller } from "#/models/Doller";
import { Money } from "#/models/Money";
test("doller addition", () => {
const three: Doller = Money.doller(3);
const four: Doller = Money.doller(4);
const reslut: Doller = three.add(four);
expect(reslut.amount).toBe(7);
const five: Doller = Money.doller(5);
const reslut2: Doller = three.add(five);
expect(reslut2.amount).toBe(8);
})
`
コンパイルエラーが2種類出ている。
- Moneyクラスにdollerがないこと
- Moneyクラスにaddがないこと
Moneyクラスにdoller()をstaticで実装し、abstractでaddを追加する。
`jsx
import { Doller } from './Doller'
export abstract class Money {
public amount: number
constructor(amount: number) {
this.amount = amount
}
static doller(amount: number): Doller {
return new Doller(amount)
}
abstract add(money: Money): Money
}
`
Dollerを削除したいので、Dollerは全てMoneyへ変えたい。
テストをまず変える。
`jsx
import { Money } from "#/models/Money";
test("doller addition", () => {
const three: Money = Money.doller(3);
const four: Money = Money.doller(4);
const reslut: Money = three.add(four);
expect(reslut.amount).toBe(7);
const five: Money = Money.doller(5);
const reslut2: Money = three.add(five);
expect(reslut2.amount).toBe(8);
})
`
その後、実装コードも
`jsx
export abstract class Money {
public amount: number
constructor(amount: number) {
this.amount = amount
}
static doller(amount: number): Money {
return new Money(amount)
}
abstract add(money: Money): Money
}
`
抽象クラスはインスタンスを作れないので、addを実装し、abstractを削除する。
# コンパイルエラー解消後のコード
テスト
`jsx
import { Money } from "#/models/Money";
test("doller addition", () => {
const three: Money = Money.doller(3);
const four: Money = Money.doller(4);
const reslut: Money = three.add(four);
expect(reslut.amount).toBe(7);
const five: Money = Money.doller(5);
const reslut2: Money = three.add(five);
expect(reslut2.amount).toBe(8);
})
test("yen addition", () => {
const threeHundred: Money = Money.yen(300);
const fourHundred: Money = Money.yen(400);
const reslut: Money = threeHundred.add(fourHundred);
expect(reslut.amount).toBe(700);
const fiveHundred: Money = Money.yen(500);
const reslut2: Money = threeHundred.add(fiveHundred);
expect(reslut2.amount).toBe(800);
})
`
Money
`jsx
export class Money {
public amount: number
constructor(amount: number) {
this.amount = amount
}
static doller(amount: number): Money {
return new Money(amount)
}
static yen(amount: number): Money {
return new Money(amount)
}
add(money: Money): Money {
return new Money(this.amount + money.amount)
}
}
`
- 足し算
- x 3ドル+4ドル=7ドル
- x **副作用(threeなのに7ドルになってしまう問題)どうする?**
- プロパティがpublic問題
- x 300円+400円=700円
- x DollerとYenの重複
- yenとdollerの区別
# 通貨
テスト
`jsx
test("currency", () => {
const doller = Money.doller(5)
expect(doller.currency).toBe('USD');
const yen = Money.yen(100)
expect(yen.currency).toBe('JPY');
})
`
currencyがないというコンパイルエラーを解消する。
これは明確な実装ができる箇所と、そうじゃない箇所がある。
生成する箇所は明確にかけるが、addはまだ実装が定まらないので、とりあえず'USD'を入れておく。
`jsx
const Currency = {
Doller: 'USD',
Yen: 'JPY'
} as const
type Currency = typeof Currencykeyof typeof Currency
export class Money {
public amount: number
public currency: Currency
constructor(amount: number, currency: Currency) {
this.amount = amount
this.currency = currency
}
static doller(amount: number): Money {
return new Money(amount, Currency.Doller)
}
static yen(amount: number): Money {
return new Money(amount, Currency.Yen)
}
add(money: Money): Money {
return new Money(this.amount + money.amount, Currency.Doller)
}
}
`
これでテストを実行するとグリーンとなる。
足し算するとドルになってしまう問題はコードとして正しくない。
とりあえず、同じ通貨での足し算しか行われない想定で、リファクタリングをする。(と同時にTODOリストに異なる通貨での足し算について記載しておく)
`jsx
add(money: Money): Money {
return new Money(this.amount + money.amount, this.currency)
}
`
とした。
テストはグリーンである。
- 足し算
- x 3ドル+4ドル=7ドル
- x **副作用(threeなのに7ドルになってしまう問題)どうする?**
- プロパティがpublic問題
- x 300円+400円=700円
- x DollerとYenの重複
- x yenとdollerの区別
- 異なる通過での足し算
# アクセス修飾子の見直し
プロパティがpublic問題を解消しよう。
いま足し算のテストで
`jsx
const three: Money = Money.doller(3);
const four: Money = Money.doller(4);
const reslut: Money = three.add(four);
expect(reslut.amount).toBe(7)
`
と書いて直接amountをgetしているが、これだと表現が乏しすぎる。
通貨という概念がMoneyに入ってきたので、これだと7ドルと7円が同一であるコードになってしまう。
だからなるべくプロパティをgetして一要素だけ比較できない形にしておく方が安全である。
該当箇所のテストを次のように書き換える。
`jsx
expect(reslut.equals(Money.doller(7))).toBe(true);
`
これで7ドルと7ドルの比較ができるはずである。
また、7ドルと7円が異なることをテストする必要もある。
equalsテストを追加する。
`jsx
test("equals", () => {
expect(Money.doller(7).equals(Money.doller(7))).toBe(true);
expect(Money.yen(7).equals(Money.doller(8))).toBe(false);
expect(Money.yen(7).equals(Money.doller(7))).toBe(false);
})
`
`jsx
import { Money } from "#/models/Money";
test("currency", () => {
const doller = Money.doller(5)
expect(doller.currency).toBe('USD');
const yen = Money.yen(100)
expect(yen.currency).toBe('JPY');
})
test("equals", () => {
expect(Money.doller(7).equals(Money.doller(7))).toBe(true);
expect(Money.yen(7).equals(Money.doller(8))).toBe(false);
expect(Money.yen(7).equals(Money.doller(7))).toBe(false);
})
test("doller addition", () => {
const three: Money = Money.doller(3);
const four: Money = Money.doller(4);
const reslut: Money = three.add(four);
expect(reslut.equals(Money.doller(7))).toBe(true);
const five: Money = Money.doller(5);
const reslut2: Money = three.add(five);
expect(reslut2.equals(Money.doller(8))).toBe(true);
})
test("yen addition", () => {
const threeHundred: Money = Money.yen(300);
const fourHundred: Money = Money.yen(400);
const reslut: Money = threeHundred.add(fourHundred);
expect(reslut.equals(Money.yen(700))).toBe(true);
const fiveHundred: Money = Money.yen(500);
const reslut2: Money = threeHundred.add(fiveHundred);
expect(reslut2.equals(Money.yen(800))).toBe(true);
})
`
これは明確に実装できる。
`jsx
equals(money: Money): boolean {
return this.currency === money.currency && this.amount === money.amount
}
`
これでamountがpublicである必要がなくなった。
privateに変更し、テストを実行する。
間違いなくグリーンである。
- 足し算
- x 3ドル+4ドル=7ドル
- x **副作用(threeなのに7ドルになってしまう問題)どうする?**
- x プロパティがpublic問題
- x 300円+400円=700円
- x DollerとYenの重複
- x yenとdollerの区別
- 異なる通貨での足し算
ここで、最初にあった変換のTODOに戻る。
- 変換する
- 3ドルを300円に
- 300円を3ドルに
まだ実装方法が思いつかないので、テストを書いてみる。
なお値オブジェクトが為替レートを知るのはおかしいので、サービスオブジェクトを用いる。
一旦振る舞いだけあればよいのでstaticメソッドとする。
`jsx
test("exchange", () => {
const doller: Money = Money.doller(3)
const reslut: Money = Bank.exchange(doller, Currency.Yen)
expect(reslut.equals(Money.yen(300))).toBe(true)
})
`
コンパイルエラーを解消する。
なお、まだ変換ができないので、仮実装を入れてグリーンにする。
`jsx
import { Currency } from "#/constants/Currency"
import { Money } from "#/models/Money"
export class Bank {
static exchange(money: Money, currency: Currency): Money {
return new Money(300, currency)
}
}
`
- 変換する
- x 3ドルを300円に
- 300円を3ドルに
テスト
`jsx
test("exchange", () => {
const doller: Money = Money.doller(3)
const reslut: Money = Bank.exchange(doller, Currency.Yen)
expect(reslut.equals(Money.yen(300))).toBe(true)
const yen: Money = Money.yen(300)
const reslut2: Money = Bank.exchange(yen, Currency.Doller)
expect(reslut2.equals(Money.doller(3))).toBe(true)
})
`
テストを実行するとレッドになる。
これを実装する。
今は2種類しか通貨がないから、変換前だけでrateがわかる。
`jsx
import { Currency } from "#/constants/Currency"
import { Money } from "#/models/Money"
export class Bank {
static exchange(money: Money, to: Currency): Money {
const rate: number = getRate(money.currency, to) // fromとtoを指定して叩けるAPIがあることにしてください。
const exchangedMoney: Money = money.multiplicate(rate)
return new Money(exchangedMoney.amount, to)
}
}
`
- multiplicateがない
- amountのgetで定義されてない
ここでmultiplicateがないことに気付く。
なので実装コード、テストコードを戻してから、掛け算のテストと実装をする。
複数タスクを並行しないこと、テストを書いてから実装することがルールなので。
- 変換する
- x 3ドルを300円に
- **掛け算**
- **amountをgetで定義**
- 300円を3ドルに
掛け算。
これは明白なので、テスト書いて実装して実行。
`jsx
test("multiplication", () => {
const three: Money = Money.doller(3);
const reslut: Money = three.multiplicate(2);
expect(reslut.equals(Money.doller(6))).toBe(true);
const threeHundred: Money = Money.yen(300);
const reslut2: Money = threeHundred.multiplicate(3);
expect(reslut2.equals(Money.yen(900))).toBe(true);
})
`
`jsx
multiplicate(n: number): Money {
return new Money(this.amount * n, this.currency)
}
`
amount
`jsx
private _amount: number
にして
get amount(): number {
return this._amount
}
`
これを実行するとグリーンとなる。
- x 変換する
- x 3ドルを300円に
- x **掛け算**
- x **amountをgetで定義**
- x 300円を3ドルに
`jsx
test("exchange", () => {
const doller: Money = Money.doller(3)
const reslut: Money = Bank.exchange(doller, Currency.Yen)
expect(reslut.equals(Money.yen(300))).toBe(true)
const yen: Money = Money.yen(300)
const reslut2: Money = Bank.exchange(yen, Currency.Doller)
expect(reslut2.equals(Money.doller(3))).toBe(true)
const reslut3: Money = Bank.exchange(doller, Currency.Doller)
expect(reslut3.equals(Money.doller(3))).toBe(true)
})
`
レッドなので、修正をする。
- 足し算
- x 3ドル+4ドル=7ドル
- x **副作用(threeなのに7ドルになってしまう問題)どうする?**
- x プロパティがpublic問題
- x 300円+400円=700円
- x DollerとYenの重複
- x yenとdollerの区別
- 異なる通貨での足し算
テストを書く。
`jsx
test("multiple currencies addition", () => {
const doller: Money = Money.doller(5);
const yen: Money = Money.yen(300);
const currency: Currency = Currency.Doller
const reslut: Money = Bank.exchange(doller, currency)
.add(Bank.exchange(yen, currency));
expect(reslut.equals(Money.doller(8))).toBe(true);
})
`
テストを実行し、グリーンなので、修正をしない。
- x 足し算
- x 3ドル+4ドル=7ドル
- x **副作用(threeなのに7ドルになってしまう問題)どうする?**
- x プロパティがpublic問題
- x 300円+400円=700円
- x DollerとYenの重複
- x yenとdollerの区別
- x 異なる通貨での足し算
# まとめ
TDDは設計手法と言われるが、設計の答えを教えてくれるわけではない。
TDDについての書籍や記事を何個か読んだ結果、結果的な設計のゴールがDDDのそれとなってるものが多かった。
TDDが多くの現場でコストの割にメリットが少ないと言われるのは、上記の通り答えを教えてくれないから。
(TDDは設計の答えを知ってる人に対して、間違える頻度を減らしてくれるというものであり、答えを得ることができるというものではない。)
※ここでいう答えとは、完璧な設計というよりは、よりよい設計くらいのニュアンスです。
## なぜ設計手法と呼ばれるか
TDDは、テストと実装を繰り返す中で、設計のゴールを意識する機会を何回も与えてくれる。
(=動作する綺麗なコードになる。)
そもそも持続するプロダクトの設計に完璧な答えはない。
ある地点の仕様に対して完璧な設計は(もしかしたら)あるかもしれないが、それが未来でも完璧であり続ける可能性は低い。
TDDは設計とコードに思い切った変更を加えてやすくしてくれる。
つまり、常に綺麗なコードを書くことと、変化を受け入れること
そういう意味で設計手法と呼ばれているのだと思われる。
# 個人的な感想
テストから書く、ということはエディタによる補完なしで書かないといけないことが多いので
それがつらい。
テストを書くタイミングが一番設計を考える。実装がない状態でテストをかくの難しすぎる。
次の発表時に表示側の話をするかもしれない。
TDDのリズムの話は十分かと思うので、表示側は量は少ないかもしれないのでそしたら記事作るだけ。
#勉強会